iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 29
1
Software Development

30天完成家庭任務平台系列 第 29

30天完成家庭任務平台:第二十九天

  • 分享至 

  • xImage
  •  

我最近試著把家庭任務平台的前後端分離時,後端要開出API給前端來抓取資料,但因為家庭任務平台會有權限限制,例如只有建立計畫的人才能看到計畫,Laravel有提供一個方便的套件Sanctum來處理這樣的狀況。

Sanctum可以提供單頁應用程式認證、手機應用程式、APIs的Token認證。單頁應用程式認證和APIs的Token認證採取不同的機制:(1)單頁應用程式認證: cookie based session;(2)APIs的Token認證:Bear Token。開發者可以根據自己的狀況任選其中一個機制來使用,但如果可以適用單頁應用程式認證,即前端和後端的頂級網域名稱相同,則建議使用單頁應用程式認證,因為其提供的防護如防CSRF更為周全。

由於前端並不擁有相同的頂級網域名稱,我們使用APIs的Token認證:

  1. 安裝Sanctum
    composer require laravel/sanctum
    php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"

  2. 利用User的HasApiTokens來發Token

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;
}
  • Token會在雜湊處理後存入資料庫,此時回傳未經雜湊處理的Token給前端:
$token = $user->createToken('token-name');
return $token->plainTextToken;
  • 使Token無效的方法:
$user->tokens()->delete();

// Revoke the user's current token...
$request->user()->currentAccessToken()->delete();

// Revoke a specific token...
$user->tokens()->where('id', $id)->delete();

token-name:自取。

  1. 處理認證的AuthController
  • AuthController
class AuthController extends Controller
{
    public function register(RegisterRequest $request)
    {
        $validatedData = $request->validated();
        $validatedData['password'] = Hash::make(request('password'));
        $user = User::create($validatedData);
        return $this->userResponse($user, 201);
    }

    public function login(LoginRequest $request)
    {
        $validatedData = $request->validated();
        if (!Auth::attempt($validatedData)) {
            return response()->json(
                ["message" => "The credential was invalid."],
                401
            );
        }
        $user = User::where('email', $validatedData['email'])->first();
        return $this->userResponse($user, 200);
    }

    public function logout(Request $request)
    {
        $request->user()->currentAccessToken()->delete();
        return response()->json([],204);
    }
    protected function userResponse(User $user, int $status)
    {

        $token = $user->createToken('familyboard-apis');
        return response()->json(['accessToken' => $token->plainTextToken, 'type' => 'Bearer'], $status);
    }
}
  • RegisterRequest
class RegisterRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [  
                'name' => ['required', 'string', 'max:255'],
                'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
                'password' => ['required', 'string', 'min:8', 'confirmed'], 
        ];
    }
    public function messages()
{
    return [
        'name.required' => 'A name is required',
        'email.required' => 'A email is required',
        'email.unique'=>'The email already exists',
        'password.required' => 'A password is required',
    ];
}
}
  • LoginRequest
class LoginRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'email' => ['required', 'string', 'email', 'max:255', 'exists:users'],
            'password' => ['required', 'string', 'min:8'],
        ];
    }
    public function messages()
{
    return [
        'email.required' => 'A email is required',
        'email.exists'=>'The email is not registered yet',
        'password.required' => 'A password is required',
    ];
}
}

  1. 測試我們的AuthController
class LoginAndRegisterTest extends TestCase
{
    use RefreshDatabase, WithFaker;
    /**
     * A basic feature test example.
     *
     * @test
     */
    public function a_registered_user_can_get_a_token()
    {
        $response = $this->post(
            route('register'),
            [
                'name' => 'jhao',
                'email' => 'jhao@gmail.com',
                'password' => '12345678',
                'password_confirmation' => '12345678'
            ]
        );

        $this->assertDatabaseHas('users', ['email' => 'jhao@gmail.com']);
        $this->assertDatabaseHas(
            'personal_access_tokens',
            [
                'tokenable_type' => 'App\Models\User',
                'tokenable_id' => User::where('name', 'jhao')->first()->id
            ]
        );
        $response->assertStatus(201);
    }

    /** @test */
    public function registering_twice_would_receive_status_422()
    {
        $user = User::factory()->create();
        $response = $this->post(
            route('register'),
            [
                'name' => $user->name,
                'email' => $user->email,
                'password' => '12345678',
                'password_confirmation' => '12345678'
            ],
        );

        $response->assertStatus(422);
        $response->assertExactJson(["message" => "The given data was invalid.", "errors" => ["email" => ["The email already exists"]]]);
    }

    /** @test */
    public function logged_in_user_must_have_a_registered_email()
    {
        $response = $this->post(route('login'), ['email' => 'jhao@gmail.com', 'password' => '12345678']);
        $response->assertStatus(422);
        $response->assertExactJson(["message" => "The given data was invalid.", "errors" => ["email" => ["The email is not registered yet"]]]);
    }

    /** @test */
    public function logged_in_user_must_have_valid_credential()
    {
        $user = User::factory()->create(['password' => '1234567']);
        $response = $this->post(route('login'), ['email' => $user->email, 'password' => '1qaz2wsx']);
        $response->assertStatus(401);
        $response->assertExactJson(["message" => "The credential was invalid."]);
    }
    /** @test */
    public function logged_in_user_can_get_an_access_token()
    {
        $user = User::factory()->create(['password' => Hash::make('12345678'), 'name' => 'jhao']);
        $response = $this->post(route('login'), ['email' => $user->email, 'password' => '12345678']);
        $response->assertStatus(200);
    }

}

  1. 加入auth:sanctum的Middleware來保護需要認證的路由,使得我們可以利用$request->user()來辨識前端使用者的身份
Route::prefix('v1')->group(function () {
    Route::post('register', [AuthController::class, 'register'])->name('register');
    Route::post('login', [AuthController::class, 'login'])->name('login');
    
});


Route::prefix('v1')->middleware('auth:sanctum')->group(function () {
    Route::get('logout', [AuthController::class, 'logout'])->name('logout');
    Route::apiResource('projects',ProjectController::class);
});

此外,為了讓api路由都會在Header中加入['Accept', 'application/json'],做一個AddJsonHeader的Middleware。

class AddJsonHeader
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {

        $request->headers->set('Accept', 'application/json');

        return $next($request);
    }
}

'api' => [
            \App\Http\Middleware\AddJsonHeader::class,
            'throttle:api',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
  1. 測試logout路徑是否受到保護
class LoginAndRegisterTest extends TestCase
{
     ...
    /** @test */
    public function logged_out_user_can_not_access_website()
    {
        $user = Sanctum::actingAs(
            User::factory()->create(),
            ['*']
        );
        $this->get(route('projects.index'))->assertSuccessful();
        $this->get(route('logout'))->assertStatus(204);
        $this->assertDatabaseMissing(
            'personal_access_tokens',
            [
                'tokenable_id' => $user->id,
                'tokenable_type' => 'App\Models\User'
            ]
        );
       
    }
  ...
 }
  • Laravel提供經過驗證使用者的機制:
    $user = Sanctum::actingAs( User::factory()->create(), ['*'] );

參考文章
Laravel Sanctum


上一篇
30天完成家庭任務平台:第二十八天
下一篇
30天完成家庭任務平台:第三十天
系列文
30天完成家庭任務平台30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言